vLLM 源码 · 2026-10-16 发布 · vllm-ascend NPU Profiling 实战笔记
排查的起点很朴素:同一套 GLM-5.1 服务换了推理镜像后变慢——新镜像(10-09 构建,下文简称 1009)比老镜像(08-15 构建,下文简称 0815)的 TTFT(首 token 时延,用户发出请求到收到第一个字)慢了 15% 至 20%,到底慢在哪个算子上?端侧指标(TTFT/吞吐)只能说明“慢了”,profiling 才能回答“慢在谁身上、慢多少、什么 shape 下慢”。这次它交出的答案:
没有 profiling,这个故事到“TTFT 慢 15-20%”就断了。
vllm-ascend 采集 NPU profiling 不用改代码,启动时挂上 --profiler-config(注意:独立参数,不是 additional_config 里的字段——放错位置 pydantic 会报 unexpected keyword):
--profiler-config '{"profiler": "torch", "torch_profiler_dir": "/home/ywx1279670/profile", "torch_profiler_with_stack": false}'
profiler: "torch":走 torch_npu profiler 路线(另有 mstx 等),导出 Ascend 侧解析结果;torch_profiler_dir:数据落盘目录,确保所在盘有几十 GB 空闲;torch_profiler_with_stack: false:不采调用栈。带栈的体积和噪声都大,定位 kernel 级问题不需要它。第一次全量采集直接干出 257GB,几乎导不动也析不动。后来固化成“压测中段 + 15 秒短窗口”的姿势,三个窗口 du -sh 实测 13GB / 14GB / 18GB(-93%),ASCEND 输出解析约 1.5 分钟:
1010_cc8_mlapo、prefill 窗口 1010_cc8_prefill2(目录名=采集日期_压测档_用途),天然把阶段分开,省得在一份数据里按 step 猜阶段。这次的真实路径与体积(du -sh / ls -lh 实测):
/home/ywx1279670/profile/
├── 1010_cc8_mlapo/ # 1009 decode 短窗口 · 18GB
├── 1010_cc8_prefill2/ # 1009 prefill 短窗口 · 14GB
│ └── dp0_pp0_tp0_dcp0_ep0_rank0_289383_20261010221318731_ascend_pt/
│ ├── ASCEND_PROFILER_OUTPUT/ # ★ 自动解析结果,分析主要就看这里
│ │ ├── kernel_details.csv 209M # ★ 每个 kernel 的每次执行一行(659657 行)
│ │ ├── ascend_pytorch_profiler_0.db 744M # 原始时序库(最大的一块)
│ │ ├── task_time.csv 138M
│ │ ├── communication.json 85M # 通信时序(看重叠时用)
│ │ ├── operator_details.csv 31M # host 侧算子明细
│ │ ├── analysis.db 8.1M
│ │ ├── api_statistic.csv 8.1K # host 侧 API 统计(很小,直接看)
│ │ ├── op_statistic.csv 4.7K # ★ 按算子聚合(很小,直接看)
│ │ ├── communication_matrix.json 10K
│ │ ├── step_trace_time.csv 295B # 每个 step 的计算/通信拆解(一行一条)
│ │ └── analyse.done 0B # 解析完成标记
│ └── PROF_*/ host_info.log / device_*.log # 原始二进制与日志(一般不动)
├── 1010_cc8_0815_prefill/ # 0815 prefill 短窗口(对照组)· 13GB
│ └── dp0_pp0_tp0_dcp0_ep0_rank0_165753_20261010232101886_ascend_pt/
│ └── ASCEND_PROFILER_OUTPUT/ (kernel_details.csv 200M · 636120 行,结构同上)
└── parsed_r0/k10_rank0.csv # 早期一次采集的解析结果(留存对比用)
两个细节:①目录名里带 dp0_pp0_tp0_dcp0_ep0_rank0(并行布局与 rank)和采集时刻时间戳(如 20261010221318731),对比两份数据前先对时间戳与 rank;②这套工具链导出里没有 profiler_info.json / CHROME_TRACE,不同版本文件清单略有差异,以实际 ls 为准。
| 文件 | 粒度 | 什么时候用它 |
|---|---|---|
| kernel_details.csv | device 上每个 kernel 每次执行一行 | 主力文件:按 kernel 名聚合、看分布(P50/P99)、按 shape 分阶段,全靠它 |
| op_statistic.csv | 按算子聚合后的统计 | 快速看“谁总耗时最大”,但看不到分布和 shape 细节,定位长尾必须回 kernel_details |
| step_trace_time.csv | 每个 step 一行 | 看计算/通信拆解与重叠(本例全文只有一行,见下方实际数据);没分窗口采时靠它分阶段 |
| api_statistic.csv | host 侧 API 聚合统计,几 KB | 看 CPU 侧谁在烧时间(tiling、拷贝);与 op_statistic 分工:它看 host,op_statistic 看 device |
| communication.json / communication_matrix.json | 通信时序 / 矩阵 | 看通信与计算重叠、卡间流量;本例 85M,按需加载 |
ASCEND_PROFILER_OUTPUT 是自动的(torch 路线导出即解析,约 1.5 分钟);torch_npu.analyse 接口也可以手动触发;msprof --parse=on 我们试过一次失败——优先用自动产物,别恋战。真实表头(两版本一致):
Device_id, Model ID, Task ID, Stream ID, Name, Type, OP State, Accelerator Core,
Start Time(us), Duration(us), Wait Time(us), Block Num, Mix Block Num, HF32 Eligible,
Input Shapes, Input Data Types, Input Formats, Output Shapes, Output Data Types, Output Formats,
Context ID, aicore_time(us), aic_total_cycles, aic_mac_time
| 列 | 本例的样子 | 分析时怎么用 |
|---|---|---|
| Name | DispatchFFNCombine、aclnnQuantMatmulWeightNz_QuantBatchMatmulV3_... | 聚合维度;str.contains 锁目标算子 |
| Duration(us) | 如 3963.123(小数,微秒) | 核心指标:sum 看总量、分位数看分布 |
| Input Shapes | "220,6144;8,6144,4096;8,2048,6144;..." | 本案例的关键钥匙:按它分组才能拆出 prefill 大 chunk 调用 |
| Start Time(us) | 纳秒级时间戳(如 1791641598803750.295) | 切窗口、看时序(是否与通信重叠) |
| Accelerator Core | AI_CORE / AI_VECTOR_CORE / AI_CPU / MIX_AIC | 区分 cube / vector / cpu kernel; MIX_AIC = 融合大算子 |
| Device / Stream / Task ID | 0 / 47 / 43982 | 多卡多流时区分归属;单 rank 分析基本不用管 |
kernel_details.csv 前 2 行数据(每行截断,真实内容)——开头都是些 1~3us 的小 kernel:
0,4294967295,43982,47,aclnnInplaceFillScalar_FillAiCore_Fill,Fill,dynamic,AI_VECTOR_CORE,1791641598803750.295,1.560,0,1,0,NO,"""1;""",INT64;INT32,ND;ND,"""9""",INT32,ND,...
0,4294967295,43987,47,aclnnIndex_IndexCheck_IndexCheck,IndexCheck,dynamic,AI_VECTOR_CORE,1791641598804008.502,3.120,256.690000,4,0,NO,"""1;4""",INT64;INT64,ND;ND,N/A,N/A,N/A,...
op_statistic.csv(4.7KB,全文可直接看)前几行——30 秒摸底就看它:DispatchFFNCombine 一家占 40.5%:
Device_id,OP Type,Core Type,Count,Total Time(us),Min Time(us),Avg Time(us),Max Time(us),Ratio(%)
0,DispatchFFNCombine,MIX_AIC,12007,4574397.762,204.794,380.978,14539.16,40.534
0,QuantBatchMatmulV3,MIX_AIC,54036,1378404.569,9.162,25.509,290.756,12.214
0,SparseFlashAttention,MIX_AIC,12469,1284798.358,93.464,103.039,938.124,11.385
0,TransposeBatchMatMul,AI_CORE,24939,549558.977,18.224,22.036,85.243,4.87
0,allgatherAicpuKernel,AI_CPU,249,467424.672,279.793,1877.208,65305.219,4.142
0,MatMulV2,AI_CORE,17212,407542.289,11.863,23.678,223.238,3.611
0,reduce_scatterAicpuKernel,AI_CPU,171,172385.056,246.744,1008.1,5012.683,1.528
step_trace_time.csv(295B,全文就一行)——这个窗口计算为主、通信大半被重叠:
Device_id,Step,Computing,Communication(Not Overlapped),Overlapped,Communication,Free,Stage,Bubble,...
0,,10604437.507, 1318080.581, 38628.661, 1356709.242, 451331.072, 12373849.25, 0,...
读法:窗口总时长约 13.9s,其中 Computing 10.6s(76%),不重叠通信 1.32s(9.5%),Overlapped 仅 38.6ms——说明通信几乎都被计算盖住了,瓶颈在 kernel 执行本身,这也是后面敢把矛头指向单算子耗时的依据之一。
api_statistic.csv 前几行——host 侧视角,看 CPU 侧谁在烧时间:
Device_id,Level,API Name,Time(us),Count,Avg(us),Min(us),Max(us),Variance
host,communication,AivKernel,157403.46,6271,25.1,18.48,70.74,10.974
host,acl,Cast_Tiling,20853.01,1232,16.926,5.18,313.13,289.435
host,acl,ForeachAddList_Tiling,34145.23,1674,20.397,0.63,767.48,1125.884
import pandas as pd, glob
# ---- 取文件:递归找 ASCEND_PROFILER_OUTPUT 下的 kernel_details.csv ----
f = glob.glob("/home/ywx1279670/profile/1010_cc8_prefill2/**/kernel_details.csv",
recursive=True)[0]
df = pd.read_csv(f) # 本例 209MB / 659657 行,读入约 1~2 分钟
dur, name, shp = "Duration(us)", "Name", "Input Shapes"
# ---- ① 全局排行:谁总耗时最大(等价于看 op_statistic.csv,但可算分位数)----
top = (df.groupby(name)[dur]
.agg(cnt="count", total="sum", mean="mean",
p50=lambda s: s.quantile(.5), p99=lambda s: s.quantile(.99))
.sort_values("total", ascending=False).head(20))
print(top)
# ---- ② 锁定目标算子,看分布 ----
k = df[df[name].str.contains("DispatchFFNCombine", na=False)]
print(len(k), k[dur].mean(), k[dur].quantile(.5), k[dur].quantile(.99))
# ---- ③ 按 Input Shapes 分组(★关键:不按 shape 拆开看不到真差距)----
k = k.assign(shapes=k[shp].astype(str))
g = (k.groupby("shapes")[dur]
.agg(cnt="count", mean="mean",
p50=lambda s: s.quantile(.5), p99=lambda s: s.quantile(.99))
.sort_values("cnt", ascending=False))
print(g.head(6))
# ---- ④ 两版本对齐对比:同一套聚合分别跑 0815 / 1009 目录后按 shapes 拼表 ----
# cmp = g0815.join(g1009, lsuffix="_0815", rsuffix="_1009")
# 只看“大 chunk”那几行(shapes 里首维 220~222),p50/p99 逐行对比
服务器上当时的分析脚本(/tmp/analyze_tri2.py 三边对比、/tmp/deep_analyse.py shape/重叠深挖)就是这个骨架加上多目录循环和输出整理。下面的实战复盘全部是按这个模板跑出来的真实数字。
按 3.4 的模板把两份 prefill 窗口各跑一遍,下面所有表都是脚本真实输出(本次复核重跑)。
| 1009(1010_cc8_prefill2,窗口 kernel 总耗时 13.8s) | 0815(1010_cc8_0815_prefill,15.7s) | ||||||
|---|---|---|---|---|---|---|---|
| kernel | 次数 | 总ms | 占比 | kernel | 次数 | 总ms | 占比 |
| DispatchFFNCombine | 12007 | 4574.4 | 33.1% | DispatchFFNCombine | 10218 | 4772.3 | 30.4% |
| QuantBatchMatmulV3(NZ) | 65970 | 1511.0 | 10.9% | QuantBatchMatmulV3(NZ) | 55962 | 1651.6 | 10.5% |
| SparseFlashAttention | 12469 | 1284.8 | 9.3% | reduce_scatter | 400 | 1489.0 | 9.5% |
| AivKernel | 78382 | 1174.7 | 8.5% | SparseFlashAttention | 10611 | 1267.8 | 8.1% |
| TransposeBatchMatMul | 24939 | 549.6 | 4.0% | allgather | 412 | 1070.4 | 6.8% |
| allgather | 249 | 467.4 | 3.4% | AivKernel | 34714 | 724.9 | 4.6% |
| MatMulV2 | 17212 | 407.5 | 2.9% | MatMulV2 | 25283 | 562.7 | 3.6% |
| AscendQuantV2 | 41104 | 356.1 | 2.6% | AscendQuantV2 | 34977 | 303.4 | 1.9% |
| ForeachAddList | 12008 | 213.6 | 1.5% | ScatterNdUpdate | 10611 | 279.1 | 1.8% |
| reduce_scatter | 171 | 172.4 | 1.2% | BatchMatMulV2 | 10611 | 220.6 | 1.4% |
排行本身就有信息量:1009 的集合通信便宜了很多(reduce_scatter 1489→172ms、allgather 1070→467ms,这是 1009 通信优化的收益),FFNCombine 总耗时反而还低了(4772→4574ms,调用次数还多 17%)。单看总量排名,根本发现不了回退——这正是第二步要按分布、第三步要按 shape 拆的原因。
### DispatchFFNCombine 对比(全窗口池化)
0815 {'cnt': 10218, 'mean': 467.0, 'p50': 304.8, 'p99': 3971.4, 'sum_s': 4.77}
1009 {'cnt': 12007, 'mean': 381.0, 'p50': 276.3, 'p99': 4116.7, 'sum_s': 4.57}
池化口径下 1009 的 p50 / mean 反而更低——因为窗口里混着两类调用:~300us 的 decode 形状小调用(1~3 token)和 3~4ms 的 prefill 大 chunk 调用,两个窗口里两类调用的比例不一样(1009 窗口小调用 11622 次 / 0815 窗口 9672 次),直接池化会被稀释甚至比反。必须按 Input Shapes 拆开。
### 1009 prefill(按 shape 分组,Duration(us))
shapes cnt mean p50 p99
1,6144;8,6144,4096;...(decode 形状) 9672 276.8 275.7 334.0
2,6144;8,6144,4096;...(decode 形状) 1950 276.6 274.5 385.2
220,6144;8,6144,4096;...(prefill 大 chunk) 234 4117.6 3972.5 11088.4
221,6144;8,6144,4096;...(prefill 大 chunk) 151 2613.4 3710.6 4819.7
### 0815 prefill
shapes cnt mean p50 p99
2,6144;8,6144,4096;...(decode 形状) 7020 303.6 303.8 334.4
3,6144;8,6144,4096;...(decode 形状) 2652 307.5 305.3 435.7
221,6144;8,6144,4096;...(prefill 大 chunk) 468 3249.2 3436.9 5544.6
222,6144;8,6144,4096;...(prefill 大 chunk) 78 3909.8 3623.0 8034.6
| 步骤 | 做了什么 | 看到什么 | 结论推进 |
|---|---|---|---|
| 窗口准备 | 0815 / 1009 各采 15s prefill 窗口(同压测参数:输入 3.5K+输出 1.5K、32 请求、并发 8) | 两份 ..._ascend_pt/ASCEND_PROFILER_OUTPUT/(14GB / 13GB) | 可比对的前提成立 |
| ① 排行 | 两份各跑 groupby total 排行 | FFNCombine 均为第一(30~33%);通信算子 1009 明显变便宜;FFNCombine 总耗时看不出回退 | 排除 decode 窗口嫌疑;总量口径失效,转分布 |
| ② 分布 | 锁 DispatchFFNCombine 看 P50/P99(池化) | p50 反而 304.8→276.3——池化被两类调用比例稀释 | 池化口径失效,转 shape 分组 |
| ③ shape | 按 Input Shapes 分组统计 | [221,6144] 3436.9→3710.6(+8%);[220,6144] p99 11088;decode 形状无回退 | 锁定“prefill 大 chunk 路径”,decode 无关 |
| ④ 干扰排除 | step_trace 看重叠、shape 同分布检查 | Computing 76% / 不重叠通信 9.5%,大 chunk 调用与 decode 通信无重叠 | 排除调度/重叠干扰,慢是“纯执行”慢 |
| ⑤ 归因 | 对比两版本 CANN 自定义算子包 | libcust_opapi.so / libcust_opsproto_rt2.0.so / binary_info_config.json / dispatch_ffn_combine_bf16.json 的 staticKey 全部 DIFF | 0815→1009 间算子包更新了 kernel 实现 → 提单算子团队 |
enable_prefill_mc2 开关验证 TTFT 持平(753-768 vs 739-759)、mlapo(MLA 前处理融合开关)无收益,也都与“瓶颈在这个融合算子实现”自洽。profiling 证据 + 开关实验 + 端侧指标三方对上,结论才算闭环。姊妹篇:《算子与 vLLM 参数:一条链讲透》——本篇的 profiling 数据反过来用:从算子清单反推启动配置,参数影响算子的四种方式。
注:文中目录名、文件体积、行数与全部统计数字(257GB / 13~18GB / 659657 行 / op_statistic 占比 40.5% / [221,6144] P50 3436.9→3710.6us / [220,6144] p99 11088us / staticKey DIFF 等)为本次排查实测与复核重跑的真实输出;当年报告口径为 220~222 池化 P50 3469→3963us(+14%),两者方向一致、幅度随分组口径略有差异。